Skip to content

Redesign procedure 2PC skip: static analysis via PLpgSQL plugin#8566

Open
codeforall wants to merge 5 commits into
mainfrom
muusama/1_stmt_proc_poc
Open

Redesign procedure 2PC skip: static analysis via PLpgSQL plugin#8566
codeforall wants to merge 5 commits into
mainfrom
muusama/1_stmt_proc_poc

Conversation

@codeforall

@codeforall codeforall commented May 8, 2026

Copy link
Copy Markdown
Contributor

DESCRIPTION: Avoid coordinated transactions for single-statement single-shard procedure calls

Replace the runtime-counter approach from PR#8524 with pre-execution static analysis of procedure bodies using the PLpgSQL plugin func_beg hook. The prior design detected multi-statement procedures only after the first statement had run, which could produce a partial commit followed by an ERROR. Analyzing the body before any statement executes lets multi-statement procedures fall back cleanly to a coordinated (2PC) transaction with no error and no partial commit.

How it works
A new PLpgSQL plugin walks the statement tree at func_beg time, before any statement runs and sets the ProcedureBodyIsSingleStatement flag. The executor gate (CanSkipProcedureCoordination) then treats a single-statement body as necessary but not sufficient: the existing single-task / single-placement / coordinated-transaction checks still apply before a skip is allowed.

The walker:

Counts SQL-producing statements: EXECSQL, PERFORM, DYNEXECUTE, and CALL (the motivating use case).
Disqualifies on COMMIT, ROLLBACK, and every loop type (LOOP, WHILE, FOR/FORI/FORS/FORC, DYNFORS, FOREACH), a statement inside a loop can run many times, which is exactly the partial-commit risk we avoid.
Recurses into container statements: a BLOCK contributes the sum of its body and its exception-handler actions (both may run), while an IF or CASE contributes the maximum SQL count across its branches (then/ELSIF/else, or WHEN/else) because only one branch executes at runtime. A disqualifier in any branch still disqualifies the whole body.

Key changes

  • New [procedure_body_analysis.c] PLpgSQL plugin performing the pre-execution walk.
  • Max-based IF/CASE branch counting, so a procedure with a single SQL statement per branch stays eligible for the skip.
  • Multi-statement or disqualified procedures fall back to normal coordinated (2PC) transactions instead of raising an ERROR.
  • Removed the ProcedureNonCoordinatedExecutionCount global counter and its reset sites in utility_hook.c; the single-statement flag is now also defensively reset on the procedure error path so it can never survive a failed procedure.
  • The optimization stays behind the off-by-default citus.enable_procedure_transaction_skip GUC (kept off here to keep the change backport-friendly for 14.2 & 13.4; enabling-by-default / GUC removal is deferred to a follow-up on main). Note the analysis only applies to PLpgSQL procedures.
  • Regression coverage for the IF, CASE, nested-block, and exception walker paths (plus the single-CALL and loop cases), asserting the 2PC skip via citus.log_remote_commands.

Replace the runtime counter approach from PR #8524 with pre-execution
static analysis of procedure bodies using the PLpgSQL plugin func_beg
hook. This avoids partial commits followed by ERROR that the original
design could produce for multi-statement procedures.

Key changes:
- New procedure_body_analysis.c: PLpgSQL plugin that walks the statement
  tree at func_beg time, counting SQL-producing statements (EXECSQL,
  PERFORM, DYNEXECUTE, CALL) and detecting disqualifiers (COMMIT,
  ROLLBACK, and all loop types).
- Loops (FOR, WHILE, LOOP, FOREACH) are treated as disqualifiers since
  they can execute SQL multiple times, risking partial commits.
- Multi-statement procedures fall back gracefully to normal coordinated
  transactions (2PC) instead of raising an ERROR.
- Remove ProcedureNonCoordinatedExecutionCount global counter and its
  three reset sites in utility_hook.c.
- Update tests
Replace the runtime counter approach from PR #8524 with pre-execution
static analysis of procedure bodies using the PLpgSQ.

Adds a temporary benchmarking script, for testing/dev purposes only
(will not be in final commit)
@codecov

codecov Bot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 70.17544% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.69%. Comparing base (fbb67f1) to head (2b36fd4).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8566      +/-   ##
==========================================
- Coverage   88.73%   88.69%   -0.05%     
==========================================
  Files         288      289       +1     
  Lines       64428    64538     +110     
  Branches     8113     8125      +12     
==========================================
+ Hits        57170    57241      +71     
- Misses       4915     4948      +33     
- Partials     2343     2349       +6     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

}
}

count += WalkStatementList(if_stmt->else_body, disqualified);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would alternate branches of an IF statement return a count of 2 ? This, for example:

IF do_insert
THEN INSERT INTO t VALUES (x);
ELSE UPDATE t SET val = x WHERE key = y;   -- count = 2 after seeing this statement ?
END IF;

If so would something like return count + max( Walk (when), Walk(else) ) ensure only one branch is accounted for ? Nbd if not, take this as a nice-to-have suggestion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!. WalkStatementList now contributes the maximum SQL count across IF branches (then/ELSIF/else) and CASE branches (WHEN/else) instead of the sum, since only one branch runs at runtime. So an IF/CASE with one SQL statement per branch is now correctly single-statement and eligible for the skip. Safety is preserved: a branch that by itself holds ≥2 statements still counts as multi, a disqualifier (loop/COMMIT/ROLLBACK) in any branch still disqualifies the whole body via *disqualified, and sibling statements outside the IF/CASE still accumulate (sum). A BLOCK still sums its body + exception-handler action (both may run). Added TEST 23 (if_one_per_branch) and TEST 25 (case_one_per_branch) as positive skip cases, both guard-verified: reverting max→sum flips them from skip to coordinated.

* executes and set the ProcedureBodyIsSingleStatement flag.
*
* The analysis recursively walks the PLpgSQL statement tree counting
* SQL-producing statements (EXECSQL, PERFORM, DYNEXECUTE) and detecting

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's add CALL to this list, as thats the use-case that motivated this feature

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!
PLPGSQL_STMT_CALL is counted alongside EXECSQL/PERFORM/DYNEXECUTE, and the file header/contract comments were updated to reflect it. (A single-CALL procedure is now an eligible single-statement case.)

return count;
}
}
count += WalkStatementList(case_stmt->else_stmts, disqualified);

@colm-mchugh colm-mchugh May 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason not to also short-circuit after walking the else branch ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed!. Each branch walk is now followed by an explicit if (*disqualified) return count;, and after count += branchMax the top-of-loop if (*disqualified || count > 1) return count; check short-circuits the remainder,
so the walk no longer continues needlessly after the else branch resolves the count/disqualifier.

{
*disqualified = true;
return count;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it allow loop bodies with 0 SQL statements ? As it stands, this procedure body would be disqualified right?

FOR i IN 1..n LOOP
    total := total + i;  -- no SQL statements in this loop
END LOOP;
INSERT INTO t VALUES (total); -- Total SQL statement count = 1

Nbd if additional logic make this awkward, its outside of the core use-case for the feature.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we keep the unconditional loop disqualifier for now? I've documented the rationale in the loop-case comment: safely relaxing it would require proving a loop body is SQL-free at every nesting depth, the pattern is rare, and the conservative baseline only costs a missed optimization, never correctness. Left as a possible future enhancement. Added (loop_in_if) to pin that a loop nested inside an IF branch still disqualifies the whole procedure.

"Multi-statement procedures gracefully fall back to the "
"normal coordinated transaction path."),
&EnableProcedureTransactionSkip,
false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a positive change overall, let's enable the GUC by default to see impact on existing CI tests, and even consider removing the GUC and having this be the default behavior for procedure calls.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the GUC defaulting off for this PR, we can do it as a separate follow up after deciding between enabling this by default or possibly removing the GUC entirely

@ihalatci ihalatci added this to the 14.2 & 13.4 milestone Jul 8, 2026
Count the maximum number of SQL statements across IF/CASE branches
instead of their sum, so a procedure with a single statement per branch
stays eligible for the single-statement transaction skip. Reset the
single-statement flag on the procedure error path so it never survives a
failed procedure, and document why loops are disqualified and why the
feature stays behind an off-by-default GUC.

Add regression coverage for the IF, CASE, nested-block, and exception
walker paths, and remove the temporary benchmark script.
@codeforall codeforall marked this pull request as ready for review July 10, 2026 12:20
@codeforall codeforall requested a review from colm-mchugh July 10, 2026 12:20
Move plpgsql.h into its own group after postgres.h so the file passes
the sort-and-group-includes style check.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants